在 React 當中,state 是可以存放 JavaScript 中的任何資料型別,除了像是字串、數字等直接可以表示值的型別,當然也支援物件或陣列這種以參考(reference)來存取的資料型別。
然而,在 React 中當我們想更新物件或陣列型別的 state 時,你不應該直接去修改(mutate)舊的物件或陣列的內容,而是應該根據你的更新需求去產生一個新的物件或陣列。這聽起來可能有點難理解是什麼意思,讓我們以實際舉例詳細說明:
在 React 中你可以儲存任何型別的資料到 state 中:
const [number, setNumber] = useState(0);
const [name, setName] = useState('Zet');
const [isActive, setIsActive] = useState(false);
然而數字、字串、布林值...等在 JavaScript 中為「原始型別(Primitive)」的資料本來就是「不可變的(Immutable)」 的,意思是說這些值本身的內容其實本來就不能夠被「修改」,當你希望資料被更新時,只能「產生一個新的值」來「取代」舊的。
舉例來說,當你嘗試修改一個字串的內容時,其實不會有反應:
你可以看到當你嘗試 mutate str
字串的 index 0
時,它的內容不會發生任何改變。
因此當我們想要更新這段資料時,其實只能產生一個新的字串來取代舊的:
此時你做的行為是對 str
這個變數「重新賦值」,所以修改的是 str
這個變數指向的字串是誰,而不是修改舊的字串 'a string'
這個值本身的內容。
而在 React 中也一樣,原始型別的 state 資料我們必須產生新的值來取代舊的值:
// 產生一個全新的數字值 1,並傳給 setNumber 來取代舊的 0,原本的 0 這個值本身並沒有被修改
setNumber(1);
// 產生一個全新的字串值 'React',並傳給 setName 來取代舊的 'Zet',
// 原本的 'Zet' 值本身並沒有被修改
setName('React');
// 產生一個全新的布林值 true,並傳給 setIsActive 來取代舊的 false,
// 原本的 false 值本身並沒有被修改
setIsActive(true);
接著我們回到物件與陣列:
const position = { x: 0, y: 0 };
const names = ['React', 'Vue', 'jQuery'];
在 JavaScript 中,物件與陣列是屬於以參考(reference)形式存在的資料,因此一個物件或陣列本身的內容是可以被修改的(Mutable),而這種修改操作就被稱為「mutate」:
position.x = 10;
names[0] = 'JavaScript';
然而,在 React 中我們不應該去 mutate 一個物件或陣列型別的 state 資料,而是應該與對待原始型別的值一樣,產生一個新的物件或陣列去取代舊的。相較於 mutable,這種「資料一旦被決定後就不會再被事後修改」的概念我們就稱之為「immutable」。
在 JavaScript 的特性中,所有基礎型別以外的陣列或物件型別本身都是 mutable 、可以隨意修改內容的。因此如果想要做到 immutable 的效果,我們就得自行以一些安全的方法來操作資料(而不是直接以 mutate 的方式修改其屬性內容),或是依賴一些專門協助處理這個問題的第三方套件。我們也會在後續的章節介紹相關的細節。
保持 state 資料的 immutable 是 React 的核心設計概念所導致的要求,然而 JavaScript 並沒有內建 immutable 版本的物件或陣列,因此這也是 React 中少數得完全靠開發者自己手動維護並遵守的一件事情。而這也是 React 初學者最常在實際的 React 開發中因觀念理解不足而犯的錯誤。
從概念上來說,就如同我們在前面章節曾經說過的「React element 是在專門表示某個歷史時刻(某次 render)的 UI,所以一經建立就不該再被修改」。而身為原始資料的 state 也是相同道理,是在專門表示 component 某個歷史時刻(某次 render)的狀態資料,因此同樣是一經建立就不該再被修改的。
下面我們也來解釋一下,除了在概念層面,還有哪些如果我們不持續維護 immutable state 的話實際會碰到的規則與限制。
setState
的資料比較機制我們先來解析一下如果 mutate 物件或陣列 state 的話,會發生什麼問題:
import { useState } from 'react';
function App() {
const [position, setPosition] = useState({ x: 0, y: 0 });
const handleButtonClick = () => {
position.x = 10;
};
return (
<div>
<div>position: {position.x}, {position.y}</div>
<button onClick={handleButtonClick}>click me</button>
</div>
)
}
上面這段範例中當我們點擊按鈕時,會 mutate position
這個物件 state 的內容,然後你會發現瀏覽器畫面中什麼都沒有改變。這個時候你可能會覺得:「這是當然的吧,因為我們根本沒有呼叫 setPosition
方法來觸發 component re-render 與 reconciliation 啊?」
不過當我們加上呼叫 setPosition
之後,你會發現仍然無法觸發畫面的更新:
CodeSandbox: https://codesandbox.io/s/bii0w7?file=/src/App.jsx
如果你還記得的話,我們在前面的章節中介紹 reconciliation 流程時,其中有一個步驟是:
當我們呼叫 setState
時,會先以 Object.is()
方法來檢查新傳入的 state 是否與舊的不同,如果相同的話則判定資料沒有變化所以畫面不用更新,就會直接中斷接下來的流程。
而這正是為什麼上述範例中我們呼叫 setState
後什麼事情都沒發生的原因。當我們以 position.x = 10
來 mutate 這個物件 state,並且呼叫 setPosition(position)
的時候, 這個 state 裡面儲存的物件 reference 並沒有改變,仍然是原本的那個 position
物件,我們的操作只是 mutate 了原本的物件而已。因此當 React 以 Object.is()
去判定 state 是否有改變的時候,就會因為傳入 setState
的物件與原本 state 存放的物件是同一個 reference 而判定相同,進而導致根本沒有進行後續的 re-render 流程。
因此只要我們改成傳入一個新產生的物件到 setPosition
中,就能夠順利的觸發 re-render 了:
CodeSandbox: https://codesandbox.io/s/b4h3cx?file=/src/App.jsx
換句話說,當 React 在判定物件或陣列 state 是否有改變時,只會看原有資料與新資料的 reference 是否相同,完全不會去檢查物件或陣列的內容細節是否有變。因此哪怕你傳給 setState
一個與原本資料的內容一模一樣但 reference 不同的物件或陣列,React 也會判定資料有改變而進行 re-render。
這其實也呼應了前面章節中有提到過的,當資料發生改變時 React 其實不關心整包資料中具體到底改變了哪些細節,你只需要「通知」React 有資料改變(也就是呼叫 setState
方法),然後 React 會純粹用 Object.is()
去判斷「資料是不是真的有發生改變」。
Object.is()
- MDN docs
除了避免 setState
方法無法正常判斷資料有發生改變的原因之外,還有其它的原因讓我們不應該去 mutate state 的資料
在經過快要一年的努力後,本系列文的實體書版本推出了~其中新增並補充了許多鐵人賽版本中沒有的脈絡與細節,並以全彩印刷拉滿視覺上的閱讀體驗,現正熱銷中!有興趣的話歡迎參考看看,也歡迎分享給其他有接觸前端的朋友們,非常感謝大家~
《React 思維進化:一次打破常見的觀念誤解,躍升專業前端開發者》
目前首刷的軟精裝版本各大通路已經幾乎都銷售一空,接下來會再刷推出新的平裝版本:
天瓏(平裝版預購):
https://www.tenlong.com.tw/products/9786263337695
博客來(平裝版):
https://www.books.com.tw/products/0010982322
momo(平裝版):
https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=12528845